package bancosys.tec.utils.db.descriptor;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.xml.XMLConstants;
import javax.xml.transform.Source;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;

import jmine.tec.utils.UtilsMessages;

import org.jdom.Attribute;
import org.jdom.Document;
import org.jdom.Element;
import org.jdom.JDOMException;
import org.jdom.Namespace;
import org.jdom.input.SAXBuilder;
import org.jdom.transform.JDOMSource;
import org.xml.sax.SAXException;

/**
 * Classe utilitário para fazer o parse de descritores de base de referência. NÃO é thread-safe.
 * 
 * @author jrenaut
 */
public final class ReferenceDatabaseParser {

    private static final Namespace NS_REFDB = Namespace.getNamespace("ref-db", "http://www.maps.com.br/ref-db");

    private static final String TAG_SCHEMAS = "schemas";

    private static final String TAG_SCHEMA = "schema";

    private static final String TAG_DEPENDENCIES = "dependencies";

    private static final String TAG_DEPENDENCY = "dependency";

    private static final String TAG_POST = "post";

    private static final String TAG_REF = "ref";

    private static final String TAG_BASEPATH = "basePath";

    private static final String TAG_ENTRY = "entry";

    private static final String TAG_ENTRY_TYPE = "type";

    private SAXBuilder saxBuilder;

    private Map<String, ReferenceDatabaseDescription> visited = new HashMap<String, ReferenceDatabaseDescription>();

    private Set<String> visiting = new HashSet<String>();

    private Validator validator;

    private ClassLoader classLoader;

    /**
     * C'tor
     */
    public ReferenceDatabaseParser() {
        this.classLoader = this.getClass().getClassLoader();
    }

    /**
     * C'tor
     * 
     * @param classLoader O ClassLoader que deve ser utilizado para carregar recursos
     */
    public ReferenceDatabaseParser(final ClassLoader classLoader) {
        if (classLoader == null) {
            throw new IllegalArgumentException("null classloader supplied");
        }
        this.classLoader = classLoader;
    }

    /**
     * Retorna uma lista de descrições dados os nomes dos resources no classpath
     * 
     * @param classpathResources String[]
     * @return List de ReferenceDatabaseDescription
     * @throws ReferenceDatabaseException Se um arquivo não for encontrado ou um erro de parse ocorrer
     */
    public List<ReferenceDatabaseDescription> getDescriptions(final String... classpathResources) throws ReferenceDatabaseException {
        this.visited.clear();
        this.visiting.clear();

        if (classpathResources == null) {
            throw new IllegalArgumentException("Um array nulo foi recebido.");
        }

        List<ReferenceDatabaseDescription> ret = new ArrayList<ReferenceDatabaseDescription>(classpathResources.length);

        for (String classpathResource : classpathResources) {
            if (!this.visited.containsKey(classpathResource)) {
                ret.add(this.parseDescription(classpathResource, false));
            } else {
                ret.add(this.visited.get(classpathResource));
            }
        }

        return ret;
    }

    /**
     * Retorna uma lista de descrições dados os nomes dos resources no classpath
     * 
     * @param classpathResources String[]
     * @return List de ReferenceDatabaseDescription
     * @throws ReferenceDatabaseException Se um arquivo não for encontrado ou um erro de parse ocorrer
     */
    public List<ReferenceDatabaseDescription> getDescriptionsIgnoreDependencies(final String... classpathResources)
            throws ReferenceDatabaseException {
        this.visited.clear();
        this.visiting.clear();

        if (classpathResources == null) {
            throw new IllegalArgumentException("Um array nulo foi recebido.");
        }

        List<ReferenceDatabaseDescription> ret = new ArrayList<ReferenceDatabaseDescription>(classpathResources.length);

        for (String classpathResource : classpathResources) {
            if (!this.visited.containsKey(classpathResource)) {
                ret.add(this.parseDescription(classpathResource, true));
            } else {
                ret.add(this.visited.get(classpathResource));
            }
        }

        return ret;
    }

    /**
     * Faz o parse de um classpathResource
     * 
     * @param classpathResource String
     * @param ignoreDependencies boolean
     * @return ReferenceDatabaseDescription
     * @throws ReferenceDatabaseException Se o arquivo não for encontrado ou um erro de parse ocorrer
     */
    private ReferenceDatabaseDescription parseDescription(String classpathResource, boolean ignoreDependencies)
            throws ReferenceDatabaseException {

        InputStream stream = this.classLoader.getResourceAsStream(classpathResource);
        if (stream == null) {
            throw new ReferenceDatabaseException(UtilsMessages.RESOURCE_NOT_FOUND_ON_CLASSPATH.create(classpathResource));
        }

        Document document = this.getDocument(classpathResource, stream);
        Element root = document.getRootElement();

        List<ReferenceDatabaseDescription> dependencyDescriptions =
                ignoreDependencies ? new ArrayList<ReferenceDatabaseDescription>() : this.parseDependencies(classpathResource, root);
        List<ReferenceDatabaseEntry> schemaEntries = this.parseSchemas(classpathResource, root);
        List<ReferenceDatabaseEntry> postscriptEntries = this.parseEntries(classpathResource, root, TAG_POST);
        List<ReferenceDatabaseEntry> referenceEntries = this.parseEntries(classpathResource, root, TAG_REF);

        return new ReferenceDatabaseDescription(classpathResource, dependencyDescriptions, schemaEntries, postscriptEntries,
                referenceEntries);
    }

    /**
     * Faz o parse das entradas de schema.
     * 
     * @param classpathResource O caminho do resource no classpath
     * @param root O root element
     * @return Lista de ReferenceDatabaseEntry com tipo "schema"
     * @throws ReferenceDatabaseException Se um entrada de schema estiver incorreta
     */
    @SuppressWarnings("unchecked")
    private List<ReferenceDatabaseEntry> parseSchemas(String classpathResource, Element root) throws ReferenceDatabaseException {
        final List<ReferenceDatabaseEntry> schemaEntries = new ArrayList<ReferenceDatabaseEntry>();
        // tratamento dos schemas
        List<Element> children = root.getChildren(TAG_SCHEMAS, NS_REFDB);
        for (Element schemasElement : children) {
            Attribute basePathAttr = schemasElement.getAttribute(TAG_BASEPATH);
            if (basePathAttr != null) {
                String basePath = basePathAttr.getValue();
                List<Element> schemaElements = schemasElement.getChildren(TAG_SCHEMA, NS_REFDB);
                if (schemaElements != null && !schemaElements.isEmpty()) {
                    for (Element schemaElement : schemaElements) {
                        String schemaName = schemaElement.getTextTrim();
                        if (schemaName.length() > 0) {
                            schemaEntries.add(new ReferenceDatabaseEntry(basePath + "/" + schemaName, "schema"));
                        } else {
                            throw new ReferenceDatabaseException(UtilsMessages.EMPTY_SCHEMA.create(classpathResource));
                        }
                    }
                }
            }
        }
        return schemaEntries;
    }

    /**
     * Faz o parse de entradas de base de referência.
     * 
     * @param classpathResource O caminho do resource no classpath
     * @param root O root Element
     * @param tag O nome da tag de entradas
     * @return Lista de ReferenceDatabaseEntry
     * @throws ReferenceDatabaseException Se ocorrer um erro no parse
     */
    @SuppressWarnings("unchecked")
    private List<ReferenceDatabaseEntry> parseEntries(String classpathResource, Element root, String tag) throws ReferenceDatabaseException {
        List<Element> list = root.getChildren(tag, NS_REFDB);
        final List<ReferenceDatabaseEntry> ret = new ArrayList<ReferenceDatabaseEntry>();
        for (Element tagElement : list) {
            Attribute basePathAttr = tagElement.getAttribute(TAG_BASEPATH);
            if (basePathAttr != null) {
                String basePath = basePathAttr.getValue();
                // itera por todos os children inferindo pela tag o tipo de entrada
                List<Element> children = tagElement.getChildren(TAG_ENTRY, NS_REFDB);
                for (Element child : children) {
                    String typeEntry = child.getTextTrim();
                    Attribute attribute = child.getAttribute(TAG_ENTRY_TYPE);
                    String typeTag = attribute.getValue();
                    if (typeEntry.length() > 0) {
                        ret.add(new ReferenceDatabaseEntry(basePath + "/" + typeEntry, typeTag));
                    } else {
                        throw new ReferenceDatabaseException(UtilsMessages.EMPTY_TAG.create(typeTag, classpathResource));
                    }
                }
            } else {
                throw new ReferenceDatabaseException(UtilsMessages.BASE_PATH_NOT_DEFINED.create(tag, classpathResource));
            }
        }
        return ret;
    }

    /**
     * Faz o parse das dependências
     * 
     * @param classpathResource O path do resource
     * @param root O root element do xml
     * @return A lista de dependências
     * @throws ReferenceDatabaseException Se um erro de parse ocorrer
     */
    @SuppressWarnings("unchecked")
    private List<ReferenceDatabaseDescription> parseDependencies(String classpathResource, Element root) throws ReferenceDatabaseException {
        List<ReferenceDatabaseDescription> dependencyDescriptions;
        // tratamento das dependências
        Element dependenciesElement = root.getChild(TAG_DEPENDENCIES, NS_REFDB);
        if (dependenciesElement != null) {
            List<Element> dependencyElements = dependenciesElement.getChildren(TAG_DEPENDENCY, NS_REFDB);
            if (dependencyElements != null && !dependencyElements.isEmpty()) {
                this.visiting.add(classpathResource);
                dependencyDescriptions = new ArrayList<ReferenceDatabaseDescription>(dependencyElements.size());
                for (Element dependencyElement : dependencyElements) {
                    String dependencyName = dependencyElement.getTextTrim();
                    if (dependencyName.length() > 0) {
                        // se achei um nome de dependência
                        if (this.visiting.contains(dependencyName)) {
                            // se está visitando a dependência então encontramos
                            // um ciclo
                            throw new ReferenceDatabaseException(UtilsMessages.CYCLE_DETECTED.create(dependencyName, classpathResource));
                        } else {
                            // não está visitando então de duas uma
                            // ou já visitou e uso a mesma referência
                            // ou ainda não visitou então visita
                            if (this.visited.containsKey(dependencyName)) {
                                dependencyDescriptions.add(this.visited.get(dependencyName));
                            } else {
                                ReferenceDatabaseDescription description = this.parseDescription(dependencyName, false);
                                dependencyDescriptions.add(description);
                                this.visited.put(dependencyName, description);
                            }
                        }
                    } else {
                        throw new ReferenceDatabaseException(UtilsMessages.EMPTY_DEPENDENCY.create(classpathResource));
                    }
                }
                this.visiting.remove(classpathResource);
            } else {
                dependencyDescriptions = Collections.emptyList();
            }
        } else {
            dependencyDescriptions = Collections.emptyList();
        }
        return dependencyDescriptions;
    }

    /**
     * Monta o Document.
     * 
     * @param classpathResource O nome do recurso
     * @param stream InputStream
     * @return Document
     * @throws ReferenceDatabaseException Se o stream não puder ser lido ou parseado
     */
    private Document getDocument(String classpathResource, InputStream stream) throws ReferenceDatabaseException {
        if (this.saxBuilder == null) {
            this.init();
        }

        try {
            return this.validateSchema(this.saxBuilder.build(stream));
        } catch (JDOMException e) {
            throw new ReferenceDatabaseException(UtilsMessages.REFERENCE_DATABASE_PARSER_JDOMEXCEPTION.create(classpathResource), e);
        } catch (IOException e) {
            throw new ReferenceDatabaseException(UtilsMessages.REFERENCE_DATABASE_PARSER_IOEXCEPTION.create(classpathResource), e);
        } catch (SAXException e) {
            throw new ReferenceDatabaseException(UtilsMessages.REFERENCE_DATABASE_PARSER_SAXEXCEPTION.create(classpathResource), e);
        }
    }

    /**
     * Inicializa o SAXBuilder
     */
    private void init() {
        this.saxBuilder = new SAXBuilder();
        try {
            this.validator = this.initValidator();
        } catch (SAXException e) {
            throw new IllegalStateException("Erro na interpretação do schema!", e);
        }
    }

    /**
     * inicializa o validator
     * 
     * @return {@link Validator}
     * @throws SAXException e
     */
    private Validator initValidator() throws SAXException {
        final SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        InputStream input = loader.getResourceAsStream("schema/ref-db.xsd");
        if (input == null) {
            throw new IllegalStateException("Não encontrou o schema para xml de descrição de base de referência.");
        }
        Source source = new StreamSource(input);
        Schema schema = factory.newSchema(source);
        return schema.newValidator();
    }

    /**
     * @param document the doc
     * @return boolean
     * @throws IOException Erro na leitura
     * @throws SAXException Erro na validação do schema
     */
    private Document validateSchema(Document document) throws SAXException, IOException {
        this.validator.validate(new JDOMSource(document));
        return document;
    }

}
